在開發我的 AI 行程 App 時,我面臨了一個關鍵的決策:該如何選擇本地資料庫?市面上有許多優秀的套件,從輕量級的 shared_preferences、高效的 Hive 和 Isar,到功能強大的 sqflite 和 drift。經過一番研究和評估,我最終選擇了 drift。
我的行程 App 核心需求是儲存複雜、結構化的資料。每個行程都包含多個活動,而每個活動又可能包含多個子活動。這樣的需求更適合關聯式資料庫。不過,為了全面評估,我仍然將 sqflite、drift 這兩個關聯式方案,以及 Hive、Isar 這類高效的 NoSQL 方案一起納入比較。
| 套件 | 資料庫類型 | 適用情境 | 為何不選? |
|---|---|---|---|
| sqflite | 關聯式 (SQLite) | 簡單的關聯查詢,或需要完全控制 SQL 的專案 | ❌ 程式碼無型態安全,需要手動編寫大量 SQL 語句,易錯。 |
| Hive | 非關聯式 (NoSQL) | 輕量、高效的鍵值對儲存,或簡單資料快取 | ❌ 不支援複雜關聯查詢,難以處理行程與活動的多對一關係。 |
| Isar | 非關聯式 (NoSQL) | 性能敏感,資料結構有一定複雜度但關聯不嚴謹的專案 | ❌ 不適合多層次的複雜關聯,無法像關聯式資料庫那樣輕鬆管理資料完整性。 |
| Drift | 關聯式 (SQLite) | 需要處理複雜、結構化、有強關聯的資料 | ✅ 無明顯缺點,唯一挑戰是學習曲線稍高,但換來的是長期的開發效率。 |
經過比較,Drift 成為我唯一的選擇,理由非常明確:
Drift 最吸引我的地方。它利用程式碼生成技術,將我的資料表定義轉化為強型別的 Dart 類別。這意味著在編譯階段,IDE 就能即時提示我資料欄位名稱或型別的錯誤,避免了 sqflite 中常見的 SQL 語法錯誤,大大減少了除錯時間。Drift 讓我能輕鬆定義這類關聯。我可以用 Dart 程式碼來建立複雜的 JOIN 查詢,而不是手動拼接冗長的 SQL 字串。這讓我的程式碼更簡潔、可讀性更高,也更易於維護。Drift 提供了強大的 Streams API,可以讓我在資料庫資料變動時,自動觸發 UI 介面更新。例如,當我在行程中新增一個活動,行程列表會立即刷新,無需手動管理狀態,這為使用者帶來了流暢的體驗。總結來說,儘管 Drift 的學習門檻略高,但它為我的行程 App 提供了穩固的基石。它不僅解決了當前的資料儲存需求,更保證了未來功能的擴展性與程式碼的健壯性。選擇 Drift,就是選擇一種更安全、更高效、更現代的開發方式。
由於是初次使用 Drift 再加上這一個套件學習曲線較高,所以決定善用 AI,將套件教學網站丟給 Gemini 並使用職涯導師功能,幫我規劃學習路線,討論後他給了我以下目標:
要使用 Drift,需要在 pubspec.yaml 檔案中加入以下幾個套件:
dependencies:
drift: ^2.28.1
sqlite3: ^2.9.0
sqlite3_flutter_libs: ^2.9.0
path_provider: ^2.1.5
path: ^1.9.1
dev_dependencies:
drift_dev: ^2.28.1
build_runner: ^2.7.0
核心套件:
drift: 核心套件,提供 ORM 與查詢功能。sqlite3: 實際的資料庫引擎,提供 Dart 語言的 SQLite API。sqlite3_flutter_libs: 它負責將底層的 SQLite 原生函式庫(例如在 Android 和 iOS 上所需的檔案)打包進應用程式中,讓 sqlite3 套件能夠在不同平台上正常運作。path_provider: 取得不同平台上的應用程式檔案路徑(例如儲存資料庫的目錄)。path: 一個用於處理檔案路徑的函式庫,方便建立跨平台的檔案路徑。開發用套件,用於程式碼自動生成:
drift_dev: 這是 Drift 的開發工具套件,負責產生資料庫操作的程式碼。build_runner: 執行程式碼生成的指令。我將資料庫相關程式碼集中管理:
lib/
├── database/
│ ├── tables.dart # 定義所有資料表的地方
│ ├── database.dart # 資料庫實例檔案
│ ├── converters.dart # 集中放置 TypeConverter 程式碼
│ └── daos/ # 存放 DAO (Data Access Object) 檔案
│ ├── trips_dao.dart
│ ├── activities_dao.dart
│ └── child_activities_dao.dart
└── main.dart
Trips、Activities),單純描述有哪些欄位。這樣一來,資料庫層次就很分明:表格負責定義、DAO 負責操作、Database 負責統合、Converter 負責轉換。
在 Drift 中,我們可以用 Dart 程式碼直接定義資料表,而不是像傳統方式那樣寫 SQL。這種物件導向的方式,讓結構更直觀、可維護性也更高。
在模型層,我原本定義了一個 Trip 類別,包含三個屬性:id、title 和 activities。
// lib/models/trip.dart
class Trip {
final String id;
final String title;
final List<Activity> activities;
}
integer().autoIncrement() 來建立主鍵,確保唯一性並交由資料庫自動處理。TextColumn。List<Activity>,代表一個行程可能包含多個活動。但在關聯式資料庫中,表格不能直接儲存清單,否則會違反設計原則。因此,處理「一對多」關係的標準做法是:
Trips 和 Activities。Activities 表格中新增一個欄位(通常稱為 外來鍵 Foreign Key),指向它所屬的行程 ID。舉例來說:在 Activities 表格裡新增一個 trip_id 欄位,用來儲存該活動隸屬的 Trip。這樣就能透過 trip_id 反查某個行程下的所有活動。
最後,Trips 表格的實際定義如下:
// lib/database/tables.dart
import 'package:drift/drift.dart';
class Trips extends Table {
// primary key,自動產生 ID
IntColumn get id => integer().autoIncrement()();
// 行程名稱
TextColumn get name => text().withLength(min: 1, max: 32)();
}
在一個行程 (Trip) 中,會包含多個活動 (Activity),而活動本身也可能再拆成子活動 (ChildActivity)。
對應到資料庫,我使用了兩個表格:Activities 和 ChildActivities。
class Activities extends Table {
// 主鍵:活動 ID,自動遞增
IntColumn get id => integer().autoIncrement()();
// 外來鍵:指向所屬的 Trip
IntColumn get tripId => integer().references(Trips, #id)();
// 活動核心欄位
IntColumn get type => integer().map(const ActivityTypeConverter())();
TextColumn get location => text()();
DateTimeColumn get startTime => dateTime()();
IntColumn get durationInSeconds => integer().nullable()();
DateTimeColumn get endTime => dateTime()();
IntColumn get transportType =>
integer().nullable().map(const TransportTypeConverter())();
TextColumn get note => text().nullable()();
}
class ChildActivities extends Table {
// 主鍵:子活動 ID,自動遞增(而非使用 String id)
IntColumn get id => integer().autoIncrement()();
// 外來鍵:指向父層活動的 id
IntColumn get activityId => integer().references(Activities, #id)();
// 子活動核心欄位
TextColumn get name => text()();
IntColumn get durationInSeconds => integer().nullable()();
IntColumn get transportType =>
integer().nullable().map(const TransportTypeConverter())();
TextColumn get note => text().nullable()();
}
在 Activities 表格中有這段程式碼:
IntColumn get tripId => integer().references(Trips, #id)();
拆解來看:
integer():宣告這是一個整數欄位。.references(Trips, #id):建立關聯,指定要參考 Trips 表格中的某個欄位。Trips:這裡傳入的是先前定義好的 Trips 類別,表示要參考這個表格。#id:這是 Dart 的 Symbol,代表 Trips 類別裡的 id 欄位。同理,在 ChildActivities 表格中,activityId 也用同樣方式指向 Activities 的主鍵。
Duration 不能直接存在 SQLite 裡,所以我把它轉換成 整數(秒數) 來儲存:
IntColumn 是 Drift 支援的基本型別。在 Activities 和 ChildActivities 中,我用了 ActivityType 與 TransportType 這兩種 enum。
由於資料庫不支援 enum,需要使用 TypeConverter 把它轉換成 int 或 String。
範例:
class ActivityTypeConverter extends TypeConverter<ActivityType, int> {
const ActivityTypeConverter();
@override
ActivityType fromSql(int fromDb) => ActivityType.values[fromDb];
@override
int toSql(ActivityType fromDart) => fromDart.index;
}
// 在表格中使用
class Activities extends Table {
IntColumn get type => integer().map(const ActivityTypeConverter())();
}
透過 TypeConverter,程式碼中仍能用 enum,資料庫則儲存整數,兼顧可讀性與效能。
在 Drift 中,資料庫實例是 App 與資料庫溝通的唯一入口。需要先建立一個類別來代表資料庫,這個類別通常會繼承 _$AppDatabase。
@DriftDatabase(tables: [Trips, Activities, ChildActivities])
class AppDatabase extends _$AppDatabase {
AppDatabase._internal() : super(_openConnection());
// 單例模式:確保整個 App 中只會有一個資料庫實例
static final AppDatabase _instance = AppDatabase._internal();
factory AppDatabase() => _instance;
@override
int get schemaVersion => 1;
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
// 延遲初始化:App 啟動時不打開資料庫,首次查詢或寫入時才建立連線,可縮短啟動時間
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'db.sqlite'));
return NativeDatabase(file, setup: (db) {
// 設定外來鍵強制執行,維護資料完整性
db.execute('PRAGMA foreign_keys = ON;');
});
});
}
拆解說明:
@DriftDatabase(...): 傳入 Trips、Activities 和 ChildActivities 三個表格,告訴 Drift 這是資料庫的藍圖。
AppDatabase extends _$AppDatabase: _$AppDatabase 是 Drift 程式碼生成器自動產生的類別,包含與表格互動的所有程式碼。
單例模式: 透過 AppDatabase._internal() + factory AppDatabase(),確保整個 App 中只有一個資料庫實例,避免多次建立連線導致資源浪費或資料衝突。
schemaVersion: 定義資料庫版本號。當表格結構(新增、刪除、修改欄位)變更時,必須增加此數字。
LazyDatabase: 採用延遲初始化,不會在 App 啟動時打開資料庫,而是等到首次使用時才建立連線,可縮短啟動時間。
_openConnection()
db.sqlite。NativeDatabase(file, setup: ...): 新版且推薦的寫法,在資料庫打開時執行 setup 回呼函式,確保外來鍵能夠被強制執行,以維護資料完整性。當資料庫類別定義完成後,必須執行 Drift 的程式碼生成器,否則 _$AppDatabase 這個類別不會出現。
flutter pub run build_runner build
這個指令會自動產生 database.g.dart 檔案,裡面包含所有與資料表互動所需的程式碼。
到這裡,已經完成了資料庫的基礎設定:定義表格、建立資料庫實例,並且準備好程式碼生成器。這些步驟相當於打好地基,讓接下來的開發能順利展開,明天會繼續深入,實作最核心的 CRUD(Create、Read、Update、Delete)操作,讓 App 真正能夠新增、查詢、修改與刪除資料。